mirror of
https://github.com/jmcorgan/fips.git
synced 2026-10-05 19:18:25 +00:00
Merge v0.4.2 into master
Forward-merge of the maint cut. Four conflicts, each resolved in a stated direction. Cargo.toml, Cargo.lock and README.md all resolve toward master. master is 0.5.0-dev, which outranks 0.4.2, and the same logic covers the README status badge. The README also matters for a reason the plan does not mention: master's platform table carries FreeBSD and Android columns that maint's does not, because that work landed here and correctly never went to maint. Taking maint's side would have deleted those rows, and none of the plan's post-merge greps looks at the README. CHANGELOG.md is a reconciliation rather than the insertion the plan describes, because both branches already carried a [0.4.2] section: master's was the pre-reorganization copy it inherited from an earlier maint merge, still dated unreleased. The two hold the identical 80 bullets, established by sorting and diffing the bullet sets rather than by reading them, and differ only in grouping. So master takes the released section wholesale and keeps its own [Unreleased]. The reverse check passes on both blocks by set comparison: [Unreleased] holds 40 bullets matching master's pre-merge set exactly, and [0.4.2] holds 80 matching the released set exactly. No bullet is on the wrong side of that line, which is the failure every heading grep is blind to. RELEASE-NOTES.md needed no resolution. The plan expects a conflict there and warns against hand-editing the mirror pair; master never touched the file while maint replaced it, so it auto-merged.
This commit is contained in:
@@ -997,11 +997,34 @@ jobs:
|
||||
| xargs sha256sum \
|
||||
> checksums-openwrt.txt
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
||||
with:
|
||||
files: |
|
||||
dist/*.ipk
|
||||
dist/*.apk
|
||||
dist/checksums-openwrt.txt
|
||||
generate_release_notes: true
|
||||
# This job used to create the Release object itself, with
|
||||
# generate_release_notes. The three other packaging workflows wait for
|
||||
# that object instead, so whichever got there first decided what the
|
||||
# release page said: if this one won, the written notes were stranded
|
||||
# behind an auto-generated commit list. Wait like the others, so the
|
||||
# release is created once, deliberately, by whoever pushes the tag.
|
||||
- name: Wait for tag release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
for attempt in $(seq 1 20); do
|
||||
if gh release view "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Release ${GITHUB_REF_NAME} not available yet; waiting..."
|
||||
sleep 15
|
||||
done
|
||||
|
||||
echo "Timed out waiting for release ${GITHUB_REF_NAME}" >&2
|
||||
exit 1
|
||||
|
||||
- name: Upload OpenWrt assets
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release upload "${GITHUB_REF_NAME}" \
|
||||
dist/*.ipk \
|
||||
dist/*.apk \
|
||||
dist/checksums-openwrt.txt \
|
||||
--clobber \
|
||||
--repo "${GITHUB_REPOSITORY}"
|
||||
|
||||
+526
-509
File diff suppressed because it is too large
Load Diff
+630
-100
@@ -1,146 +1,676 @@
|
||||
# FIPS v0.4.1
|
||||
# FIPS v0.4.2
|
||||
|
||||
**Released**: 2026-07-19
|
||||
**Released**: 2026-08-25
|
||||
|
||||
v0.4.1 is a maintenance release on the v0.4.x line. It raises the default
|
||||
antipoison cap on inbound bloom filter announcements, removes a redundant
|
||||
spanning-tree metric counter, fixes two convergence and path-MTU bugs, and
|
||||
cuts per-packet CPU in the bloom and identity paths. There is no wire
|
||||
format change and no new feature surface.
|
||||
v0.4.2 is a maintenance release on the v0.4.x line, and the largest one
|
||||
this line has carried: 144 commits since v0.4.1. Most of it is security
|
||||
work. It closes several paths by which a party that could merely reach a
|
||||
node could take its sessions down, have its traffic attributed to
|
||||
another node, or steer that node's path MTU; it bounds a set of tables
|
||||
an unauthenticated party could grow without limit; it closes the
|
||||
fail-open cases where a local error widened what a node accepted; it
|
||||
fixes NAT traversal in two places where it was simply not working; and
|
||||
it protects private key material on disk and clears it in memory. There
|
||||
is no wire format change.
|
||||
|
||||
v0.4.1 is wire-compatible with v0.4.0. Nodes can be upgraded one at a time
|
||||
with no coordinated restart, though one behavior change below is worth
|
||||
reading before you start a rolling upgrade.
|
||||
The new configuration surface is small and optional: three admission and
|
||||
rate-limiting keys, each with a default that needs no action. The
|
||||
repository also gains a `SECURITY.md`, so someone with a finding no
|
||||
longer has to guess at an address or open a public issue.
|
||||
|
||||
v0.4.2 is wire-compatible with v0.4.1. No frame gains, loses, or resizes
|
||||
a field, so a mixed mesh works and nodes can be upgraded one at a time
|
||||
with no coordinated restart. Three changes narrow what a node accepts,
|
||||
or change how it acts on a field it already read: the session datagram
|
||||
hop limit, the path MTU floor, and routing-signal admission. Those three
|
||||
are what the interop gate is pointed at deliberately, rather than at
|
||||
connectivity alone. Compatibility is the release's intent and what that
|
||||
gate checks; it is not a claim that every mixed pairing was exercised.
|
||||
|
||||
**Read the upgrade notes before you start.** Two configuration shapes
|
||||
that loaded in v0.4.1 now refuse to start.
|
||||
|
||||
## At a glance
|
||||
|
||||
- `node.bloom.max_inbound_fpr` default moves from `0.10` to `0.20`.
|
||||
- The `parent_switched` metric counter is gone. Use `parent_switches`.
|
||||
- Spanning tree no longer serves stale coordinates after a parent link is
|
||||
lost through peer removal.
|
||||
- Discovery no longer loosens a path MTU clamp it had correctly tightened.
|
||||
- Bloom probing and identity operations do measurably less work per call,
|
||||
with identical results.
|
||||
### Before you upgrade
|
||||
|
||||
- Two configuration shapes now fail to load: a `node.rekey` interval
|
||||
that fires the trigger continuously, and a traversal signal TTL too
|
||||
large for the replay window. Both are start-time failures, so a node
|
||||
carrying either will not come back after a package upgrade.
|
||||
- macOS installs now read the ACL, hosts, config and identity files from
|
||||
`/usr/local/etc/fips/`, which is where the packaging puts them.
|
||||
|
||||
### Security
|
||||
|
||||
- Six paths by which an unauthenticated or misattributed packet changed
|
||||
a node's session state are closed. One of them terminated the daemon.
|
||||
- A remote party can no longer drive a destination's path MTU to zero,
|
||||
or aim a node's UDP punch packets at addresses of its choosing.
|
||||
- Nineteen fixes harden a node against a party that can reach it but is
|
||||
not an admitted peer: unbounded tables, unauthenticated writes and
|
||||
fail-open paths. None changes the wire format and none needs action.
|
||||
- Private keys are no longer written through a symlink, an existing
|
||||
`fips.key` has its mode retightened on every write, and a failed key
|
||||
write no longer leaves a node silently running an ephemeral identity.
|
||||
|
||||
### Connectivity and performance
|
||||
|
||||
- NAT traversal works in two deployments where it did not: a public node
|
||||
in open mode, and any host that suspends.
|
||||
- Roughly 35 ms per tick comes back at 240 peers, and multi-second
|
||||
rx-loop stalls during peer retry are gone.
|
||||
|
||||
### New configuration, all optional
|
||||
|
||||
- `node.rate_limit.session_setup_burst` / `_rate`,
|
||||
`node.rate_limit.established_handshake_burst` / `_rate`,
|
||||
`node.discovery.nostr.max_concurrent_offers_per_npub`, and
|
||||
`node.limits.max_sessions` (default 1024). Each default needs no
|
||||
action.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `cargo audit` reports no vulnerability, against twelve before, and
|
||||
every GitHub Action is pinned to a commit SHA.
|
||||
|
||||
## A note on the security content
|
||||
|
||||
Most of this release is security work, and most of that work began with
|
||||
reviews the project did not commission. Over the past month a number of
|
||||
unsolicited security reviews have arrived, and they share a character:
|
||||
they are driven by current frontier language models, their authors say
|
||||
so, and they arrive as specific, carefully written reports citing the
|
||||
code they describe rather than as vague claims.
|
||||
|
||||
The findings have been legitimate. Not every one survived a second
|
||||
reading, and several described documented behaviour as a defect. But
|
||||
enough held up under adversarial re-reading that treating this class of
|
||||
report as noise would have been a mistake, and a substantial part of
|
||||
what this release fixes was found that way, including issues in code
|
||||
that had been reviewed before.
|
||||
|
||||
None of it has been reported active in a deployment. What these reviews
|
||||
have produced are reachable defects rather than observed incidents, and
|
||||
finding them at that stage is the outcome everyone would choose.
|
||||
|
||||
This looks like a broader shift rather than something particular to this
|
||||
project. The cost of a competent first pass over an unfamiliar codebase
|
||||
has fallen sharply, and open source is benefiting from it: small
|
||||
projects are now getting the kind of attention that was previously
|
||||
reserved for large ones. We welcome it, and we would rather receive a
|
||||
report of this kind than not. `SECURITY.md` describes how to send one.
|
||||
The most useful reports are the ones that say plainly which parts were
|
||||
machine-generated and which were verified by a person, because that is
|
||||
the difference between a lead and a finding, and we assess the two
|
||||
differently.
|
||||
|
||||
## Behavior changes worth flagging
|
||||
|
||||
### The inbound filter FPR cap default doubles again
|
||||
### The session datagram hop limit now follows IP semantics
|
||||
|
||||
`node.bloom.max_inbound_fpr` goes from `0.10` to `0.20`. The cap rejects
|
||||
inbound `FilterAnnounce` frames whose advertised false positive rate
|
||||
exceeds it. On the fixed 1 KB, k=5 filter, `0.10` corresponds to a fill of
|
||||
0.631 and roughly 1,630 reachable entries, and the busiest nodes'
|
||||
aggregates had started reaching that ceiling as the mesh grew. `0.20`
|
||||
corresponds to a fill of 0.7248 and roughly 2,114 entries.
|
||||
Delivery to the addressed node is no longer gated on the hop limit, and
|
||||
a forwarder decrements before deciding rather than after. Two cases
|
||||
change on a deployed line:
|
||||
|
||||
Be aware that this is the second time in two releases that this default
|
||||
has doubled, for the same reason both times. That is worth stating plainly
|
||||
rather than repeating the previous release's framing: raising the cap buys
|
||||
headroom, it does not fix anything. The real constraint is the fixed 1 KB
|
||||
filter size, which is a protocol constant. The structural remedy is the v2
|
||||
filter work, where filter capacity scales with the mesh instead of being
|
||||
pinned. This release is an interim step to keep legitimate aggregates from
|
||||
being rejected until that lands. It is not the start of a pattern of
|
||||
raising the cap once per release, and if you are sizing capacity planning
|
||||
around this number, plan against the v2 work rather than against a third
|
||||
raise.
|
||||
| Case | v0.4.1 | v0.4.2 |
|
||||
| ---- | ------ | ------ |
|
||||
| Addressed to this node, hop limit 0 | dropped | delivered |
|
||||
| Transit datagram, hop limit 1 | forwarded at 0 | dropped here |
|
||||
|
||||
The antipoison property the cap exists for is preserved. A saturated or
|
||||
deliberately poisoned filter still presents an FPR near 100% and is still
|
||||
rejected.
|
||||
The reachable radius is unchanged, because the two behaviors compensate
|
||||
exactly: a path of `h` links still delivers for any source hop limit of
|
||||
`h` or more. During a rolling upgrade no version mix delivers less far,
|
||||
and an unupgraded forwarder feeding an upgraded destination delivers one
|
||||
hop further than either version does on its own.
|
||||
|
||||
**This matters during a rolling upgrade.** A v0.4.1 node accepts a
|
||||
`FilterAnnounce` with a derived FPR between 0.10 and 0.20; a v0.4.0 node
|
||||
drops the same frame, and the drop is silent on the wire with no NACK. The
|
||||
cap also gates the mesh size estimator, which declines to produce a value
|
||||
when any contributing filter is over the cap. So while a mesh is partly
|
||||
upgraded, upgraded and not-yet-upgraded nodes can legitimately report
|
||||
different mesh sizes, or one can report a size while the other reports
|
||||
unknown. This resolves once every node is on v0.4.1. If you want to avoid
|
||||
the window entirely, set `node.bloom.max_inbound_fpr: 0.10` explicitly in
|
||||
your config before upgrading and remove it after the last node is done.
|
||||
What an operator will see move is the counter. `TtlExhausted` now
|
||||
charges at the node that makes the decision rather than at the hop after
|
||||
it, so its distribution across a mixed mesh shifts by one hop while the
|
||||
upgrade is in progress. That is expected and is not a loss of traffic.
|
||||
|
||||
### The `parent_switched` counter is removed
|
||||
### `node.rekey.enabled` governs periodic rekey only
|
||||
|
||||
`parent_switched` was incremented on the line immediately before
|
||||
`parent_switches` at every site and never independently, so the two
|
||||
counters always held the same value. `parent_switched` is now gone from
|
||||
the tree metrics, the control socket snapshot, and the `fipstop` tree
|
||||
view. `parent_switches` remains and is unchanged.
|
||||
This is a correction to what the setting has always meant rather than a
|
||||
new field. `enabled` controls whether this node *initiates* periodic
|
||||
rekey. A rekey a peer drives is still answered when it is off, and two
|
||||
things that used to sit behind the same gate no longer do: the session
|
||||
drain sweep and the cut-over that retires an old key epoch now run
|
||||
either way. A node with rekey disabled previously held superseded keys
|
||||
for the life of the session.
|
||||
|
||||
If you scrape the control socket, or have dashboards or alerts referencing
|
||||
`parent_switched`, point them at `parent_switches`. Anything still asking
|
||||
for `parent_switched` will find nothing rather than a zero.
|
||||
### New admission defaults you may feel
|
||||
|
||||
None of these needs configuration, and none changes an existing key's
|
||||
value. They are new bounds where there was none.
|
||||
|
||||
- `node.discovery.nostr.max_concurrent_offers_per_npub` defaults to 4.
|
||||
It sits inside `max_concurrent_incoming_offers` (16), which remains
|
||||
the outer bound, so a value above that is inert.
|
||||
- `node.rate_limit.session_setup_burst` (64) and `session_setup_rate`
|
||||
(16.0) meter inbound FSP session setup per authenticated link peer. A
|
||||
legitimate peer arriving over the same link as a flooding one shares
|
||||
that link's bucket, so establishment behind a flooded neighbour is
|
||||
refused until it refills.
|
||||
- `node.rate_limit.established_handshake_burst` and `_rate` are optional
|
||||
and normally omitted; the bucket is then derived from
|
||||
`node.limits.max_peers`, `node.rekey.after_secs` and
|
||||
`node.rate_limit.handshake_max_resends`, so raising the peer limit
|
||||
sizes it automatically.
|
||||
- An accepted inbound TCP or onion connection now has a deadline for its
|
||||
first frame. This is a module constant, not a configuration key.
|
||||
- A remote-supplied path MTU below an actionable minimum is ignored
|
||||
rather than applied or stored. Locally derived MTUs are exempt at both
|
||||
the seed and the TCP MSS clamp, so a genuinely narrow link, which the
|
||||
mesh does use, still adapts.
|
||||
|
||||
### Control socket snapshots carry new fields
|
||||
|
||||
`show_routing` and `show_status` gained counters this cycle:
|
||||
`warm_malformed_packets` and `warm_malformed_bytes`, and the four
|
||||
error-signal counters `unbound_coords`, `unbound_broken`, `unbound_mtu`
|
||||
and `unbound_forged`. An older `fipsctl` or `fipstop` reading a newer
|
||||
daemon gains unknown fields rather than losing known ones. If you scrape
|
||||
those snapshots, expect additions, not removals. Two other new counters,
|
||||
the framing `payload_len_mismatch` and the setup-message refusal
|
||||
counters, are not yet readable over the control socket.
|
||||
|
||||
### The bloom announce sweep changes where its cost sits
|
||||
|
||||
Peer bloom filters are now computed for every recipient in one union
|
||||
sweep instead of being rebuilt per recipient. The result is exactly
|
||||
equal, not approximately: merging is a bytewise OR. 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 costs about twice what it did. Break-even is around three
|
||||
ready peers, so a small mesh pays slightly more and a large one pays a
|
||||
great deal less. Cadence, the debounce, the sequence rule and the
|
||||
fill-ratio cap are unchanged.
|
||||
|
||||
## Notable bug fixes
|
||||
|
||||
### Stale coordinates after losing a parent through peer removal
|
||||
Every item here is a fix for a defect that shipped in v0.4.1. Fixes for
|
||||
defects introduced and resolved inside this cycle are in the CHANGELOG
|
||||
and are not repeated here. The CHANGELOG is the complete record; this
|
||||
section is a selection.
|
||||
|
||||
When a node's parent link dropped via peer removal, the node correctly
|
||||
reparented or self-rooted, but skipped the coordinate cache invalidation
|
||||
that every other position-change path performs. Cached entries for
|
||||
downstream destinations kept the node's old coordinate prefix. This did
|
||||
not self-correct the way a stale cache entry normally would: routing
|
||||
access refreshes an entry's TTL, so an entry that was actively being
|
||||
routed through never expired, and was only fixed by an unrelated fresh
|
||||
insert. Both invalidation classes now run on this path, matching the
|
||||
loop-detection branch.
|
||||
### Session and handshake authentication
|
||||
|
||||
### Discovery could loosen a tightened path MTU clamp
|
||||
This is the release's centre of gravity. Six paths are closed, each of
|
||||
which let a packet that authenticated nothing change a node's session
|
||||
state.
|
||||
|
||||
An originator handling a `LookupResponse` overwrote its cached path MTU
|
||||
unconditionally. If a reactive `MtuExceeded` or `PathMtuNotification` had
|
||||
already taught it a tighter value, a later, looser discovery estimate
|
||||
would clobber that and re-loosen the clamp, risking a return to dropped
|
||||
oversized packets. The cached and received values are now compared and the
|
||||
tighter one is kept.
|
||||
- **A truncated inner payload terminated the daemon.** A
|
||||
`SessionDatagram` whose inner FSP payload was 4 to 11 bytes, with
|
||||
phase 0x0 and the Coords Present flag set, indexed past the end of a
|
||||
slice on the coordinate-cache warm path. The receive loop is the
|
||||
process's main future, so the panic took the daemon down rather than a
|
||||
task, and under the packaged systemd unit the node restarted into the
|
||||
same frame. Any peer past a link handshake could send it, and
|
||||
admission is default-open.
|
||||
- **An unauthenticated setup message could hold a session down.** With
|
||||
`node.rekey.enabled` false, a setup message naming an established peer
|
||||
replaced that peer's session outright, discarding the live keys. The
|
||||
message carries no authenticator and its source address is an envelope
|
||||
field the sender picks. The established case now arms a handshake
|
||||
beside the running session and adopts new keys only after a msg3 whose
|
||||
authenticated static key matches the one the session was opened with.
|
||||
- **A forged `SessionAck` cancelled an in-flight initiation**, and an
|
||||
unauthenticated msg3 discarded a completed key epoch. Both took 57
|
||||
bytes of the right length from anyone who could reach the node, and
|
||||
both were repeatable. The setup path is now also rate limited, keyed
|
||||
on the authenticated link peer the datagram arrived over rather than
|
||||
on the address the sender claims.
|
||||
- **A peer could complete a genuine handshake under another node's
|
||||
address.** The responder recorded a session under the source address
|
||||
in the datagram without checking it against the static key it had just
|
||||
authenticated, so the identity cache, the session map and the
|
||||
reconstructed mesh IPv6 all attributed that traffic to the node it
|
||||
named. The address is now derived from the authenticated key, on both
|
||||
the initial and the rekey path.
|
||||
- **Routing signals were acted on for any address.** `CoordsRequired`,
|
||||
`PathBroken` and `MtuExceeded` carry no end-to-end authentication, and
|
||||
a node applied their effects for any destination they named. They are
|
||||
now refused unless this node has itself bound that destination, and
|
||||
each refusal is counted. Signals from a genuine on-path forwarder at
|
||||
any distance are unaffected.
|
||||
- **The established-address waiver admitted the wrong party.** A
|
||||
transport with `accept_connections` false still admits an inbound msg1
|
||||
sourced from an established peer's address, so a peer re-handshaking
|
||||
after a restart is not locked out. Nothing checked that the sender was
|
||||
that peer, so any off-path party sourcing from the address obtained a
|
||||
full link handshake from a node configured to accept none. The
|
||||
handshake is now dropped once the key exchange reveals a static key
|
||||
that does not belong to the identity owning that address.
|
||||
|
||||
A frame whose declared payload length disagrees with the length that
|
||||
arrived is also now dropped at the single dispatch point, before that
|
||||
field can be used as a parsing input. This closes no known defect: on
|
||||
the stream transports the comparison holds by construction, and on the
|
||||
datagram transports a short frame already failed the AEAD tag. What
|
||||
changes is which reason it is dropped for.
|
||||
|
||||
### NAT traversal and Nostr discovery
|
||||
|
||||
- **Traversal was non-functional on a public node in open mode.** A
|
||||
signal is addressed to the merge of the peer's inbox relays, the
|
||||
relays its advert nominates, and our own, but the send was rejected
|
||||
outright if any single URL in that merge was outside the client pool
|
||||
built at startup. One unconfigured relay killed the whole attempt,
|
||||
including the sends to relays both sides shared. Measured in an
|
||||
open-mode window: 309 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 now on the
|
||||
normalized relay URL, so a trailing slash or a different host case
|
||||
does not discard a relay that is in fact configured.
|
||||
- **Traversal broke permanently after the host suspended.** The
|
||||
traversal clock cached a Unix timestamp at startup and advanced it
|
||||
with a monotonic instant, which does not tick while a machine is
|
||||
asleep, so the daemon's idea of the time trailed real time by the
|
||||
suspend duration for the rest of the process lifetime. Every
|
||||
expiration it published was already in the past, relays dropped the
|
||||
offers, and traversal stayed dead until restart. The clock now reads
|
||||
the wall clock on every call. A laptop is where this is easiest to
|
||||
hit, but any host that suspends or hibernates was affected. Reported
|
||||
in [#128](https://github.com/jmcorgan/fips/issues/128).
|
||||
- **A node could be aimed at third parties.** A rendezvous-enabled node
|
||||
punched every address a signed offer named, with no limit on how many
|
||||
one offer could carry, so any npub could have it emit a burst of UDP
|
||||
packets carrying its own source address at loopback, link-local,
|
||||
multicast, broadcast, unspecified or CGNAT addresses. Never-routable
|
||||
ranges are now rejected, IPv4-mapped forms are canonicalized first so
|
||||
they cannot slip past, port 0 is dropped, private-range candidates are
|
||||
punched only when they share a /24 with one of our own addresses, and
|
||||
the planned list is capped at eight. Each planning attempt logs what
|
||||
it declined and why.
|
||||
- **A future-dated traversal signal was accepted as strictly fresh.**
|
||||
The freshness check measured age with a saturating subtraction, which
|
||||
yields zero for any timestamp ahead of the local clock, and nothing
|
||||
else bounded the issue time from above. Forward-dating is now
|
||||
tolerated only to the same 60s of clock skew already allowed in the
|
||||
other direction, and a declared expiry is no longer trusted past the
|
||||
issue time plus the configured TTL.
|
||||
- **One sender could hold every inbound offer slot.** Admission took a
|
||||
permit from a single pool before any identity check, with the sender's
|
||||
npub used only as a log field. Admission now takes a per-npub permit
|
||||
and a global permit together. This does not make the pool
|
||||
inexhaustible: Nostr identities cost nothing to generate, so four
|
||||
throwaway npubs still saturate the shipped 16-slot pool at an
|
||||
unchanged total offer rate. What it buys is that one identity can no
|
||||
longer do it alone, and that the two refusals are distinguishable in
|
||||
the log.
|
||||
|
||||
### Path MTU
|
||||
|
||||
A single `MtuExceeded` carrying a very small value drove a session's
|
||||
path MTU to zero, after which every packet to that destination was
|
||||
answered with an ICMPv6 Packet Too Big instead of being sent: a
|
||||
blackhole lasting until the daemon restarted. The same value reached the
|
||||
SYN-time TCP MSS clamp, where anything at or below 137 saturates the
|
||||
segment size to zero. The `path_mtu` field is an unsigned per-hop
|
||||
annotation carried outside the signed proof, and `MtuExceeded` and
|
||||
`PathBroken` arrive unencrypted with no sender check, so any forwarder,
|
||||
or anyone able to reach the node, could lower it.
|
||||
|
||||
Remote values below an actionable minimum are now ignored at the three
|
||||
places a remote value is acted on, each with its own warning and
|
||||
counter, and the per-destination cache has a way back: an entry is
|
||||
released on a `PathBroken` report, on session idle expiry, and on
|
||||
handshake timeout, with the local link MTU reseeded in its place.
|
||||
Entries written by the discovery lookup carrier age out on a deadline of
|
||||
their own, because a destination this node never opens a session with
|
||||
reaches none of those three routes. Without it, one response carrying a
|
||||
floor value pinned that destination's clamp until the daemon restarted.
|
||||
|
||||
### Inbound connection slots and rekey admission
|
||||
|
||||
- **An unauthenticated remote could lock out inbound peering by staying
|
||||
silent.** The peer cap was tested at accept, with no read in between,
|
||||
and the frame reader's reads carried no deadline. Pool keys are
|
||||
`ip:port`, so N sockets from one address took N slots, and at the 256
|
||||
default that closed the node to new peers for as long as the sockets
|
||||
stayed open. The first frame now has a deadline, the onion listener
|
||||
gets the same treatment, and the handshake reaper now closes the
|
||||
transport connection it used to forget. This does not close the whole
|
||||
case: a peer that sends one well-formed frame and then goes silent
|
||||
still holds its slot.
|
||||
- **Rekey traffic was being refused on busy nodes, silently.** Rekey and
|
||||
restart msg1 on an established link competed with stranger admission
|
||||
for one shared token bucket. Measured on a field node at roughly 245
|
||||
peers: 8753 msg1 refused in 25 minutes, with 159 of the 201 distinct
|
||||
sources being peers it already held sessions with. Nothing errored and
|
||||
no session dropped, so the only symptom was a flat `rekey_armed`.
|
||||
Inbound msg1 is now classified before it is limited and draws on its
|
||||
own bucket. Nodes upgrade with no config change, and the
|
||||
`Msg1 rate limited` line now says which limb refused.
|
||||
|
||||
### Identity and key files on disk
|
||||
|
||||
- **A private key write followed a symlink**, because the single write
|
||||
path opened with create and truncate and no `O_NOFOLLOW`. Both writers
|
||||
now share an open helper that carries it.
|
||||
- **An existing `fips.key` kept a loose mode forever.** The mode was
|
||||
supplied only through `open(2)`, which the kernel honours on creation
|
||||
and ignores otherwise, so a key file at 0644 stayed 0644 through every
|
||||
rewrite. That needs no attacker: one `chmod`, or a restore that did
|
||||
not preserve modes, leaves the key readable indefinitely. The mode is
|
||||
now applied to the open descriptor before any secret bytes are
|
||||
written. On Windows neither protection applies and the file inherits
|
||||
the parent directory's ACLs; that exclusion is deliberate.
|
||||
- **A failed key write left a node running an ephemeral identity in
|
||||
silence.** Six write results in the identity path were discarded, and
|
||||
the sharpest was in `persistent` mode: a failed write to `fips.key`
|
||||
fell through to an ephemeral identity with no message, so a node asked
|
||||
for a stable identity changed its npub, its routing address and its
|
||||
mesh IPv6 on every start, and nothing said so. All six now report.
|
||||
- **Key material is now cleared when it goes out of scope.** Nothing in
|
||||
the crate erased a key before this. Clearing now covers the session
|
||||
and handshake keys, the identity keypair, the temporary copies the
|
||||
elliptic-curve operations make, encoded secrets, and the private key
|
||||
on its way through configuration, including the config file's text,
|
||||
since `node.identity.nsec` is read straight out of it. This clears the
|
||||
copies the crate owns, not every copy that ever existed: the secp256k1
|
||||
key types are copyable, and the hash, key-derivation and cached cipher
|
||||
states of the pinned libraries offer no clearing route. Reading the
|
||||
residue needs access to the process's memory, or to a core dump or
|
||||
swap image of it. **This carries a source-breaking change for library
|
||||
consumers; see the upgrade notes.**
|
||||
|
||||
### Gateway DNS answers
|
||||
|
||||
The gateway's DNS forwarder accepted whatever datagram arrived on its
|
||||
upstream socket. The upstream query reused the client's own transaction
|
||||
ID, the socket was wildcard-bound and never connected, the receive
|
||||
discarded the sender, neither the response ID nor the question was
|
||||
compared against what was asked, and the returned address was not
|
||||
checked against the mesh prefix. Because the extracted address is
|
||||
installed as a DNAT rule with no interface constraint, a forged answer
|
||||
redirected traffic rather than only poisoning a lookup.
|
||||
|
||||
The query now carries a random transaction ID, the socket is connected
|
||||
so the kernel drops foreign sources, a response must match on ID,
|
||||
question and type, and the address goes through the validating parser
|
||||
before any allocation. One deliberate behaviour change: validation sits
|
||||
before the rcode check, so an upstream answering FORMERR or REFUSED with
|
||||
an empty question section now yields SERVFAIL rather than having its
|
||||
rcode relayed. Checking after the rcode would admit a forged NXDOMAIN.
|
||||
|
||||
### macOS install layout
|
||||
|
||||
On macOS the daemon and `fipsctl` read `/etc/fips/`, a directory the
|
||||
macOS packaging does not create, while the packaging installs to
|
||||
`/usr/local/etc/fips/`. The effect was silent in the worst way: a
|
||||
populated `peers.deny` reported `effective_mode: "default_open"` with
|
||||
`enforcement_active: false` through `fipsctl acl show`, host-file
|
||||
aliases went unloaded, and `fipsctl keygen` wrote an identity where the
|
||||
daemon never read it, so the node kept an ephemeral one. The default
|
||||
paths now follow the platform's packaging, the system config search path
|
||||
includes `/usr/local/etc/fips/fips.yaml`, and a key stranded at the
|
||||
legacy path is adopted with a warning rather than a fresh identity being
|
||||
generated. Linux and Windows behavior is unchanged.
|
||||
|
||||
**macOS users with existing files in `/etc/fips/` should move them to
|
||||
`/usr/local/etc/fips/`.**
|
||||
|
||||
### Robustness under adverse local conditions
|
||||
|
||||
- **A failed log write could panic the thread or task that logged.** The
|
||||
subscriber reported its own internal errors through `eprintln!`, which
|
||||
panics when stderr has also failed, and the shipped supervisor
|
||||
configurations make that one condition rather than two: the macOS
|
||||
plist points both standard streams at one file, and the systemd units
|
||||
route both to journald, so one full disk fails both sinks together. In
|
||||
the daemon the casualty was a crypto worker, which takes its share of
|
||||
the peer space with it permanently. In `fips-gateway` it was a spawned
|
||||
task: the DNS resolver, the control accept loop or the pool tick, none
|
||||
of which is observed until shutdown, so the process kept running and
|
||||
reporting healthy with mesh name resolution or lease expiry and NAT
|
||||
cleanup stopped.
|
||||
- **Flap dampening could engage only once in a node's lifetime.** The
|
||||
arming check tested whether a deadline had ever been set rather than
|
||||
whether one was still in effect, so after the first episode a node in
|
||||
a second flap storm went on switching parents under hold-down alone,
|
||||
and neither the `flap_dampened` counter nor the warning fired again.
|
||||
Hold-down was unaffected throughout, which is why the practical cost
|
||||
at shipped settings was lost visibility rather than unrestrained
|
||||
flapping. Separately, a `node.tree.flap_dampening_secs` large enough
|
||||
to overflow the monotonic clock is now capped at one year instead of
|
||||
panicking the node when dampening engages.
|
||||
|
||||
### Supply chain
|
||||
|
||||
- The dependency lockfile moves past a set of advisories against the
|
||||
pinned `nostr` 0.44.3 and `nostr-relay-pool` 0.44.1, both of which
|
||||
were also yanked. The ones that matter here are the relay-pool
|
||||
advisories describing forged events bypassing signature validation and
|
||||
unverified relay events being processed: that is the path a node
|
||||
learns peer adverts on, and it performs no independent verification of
|
||||
its own, so the exposure was a misattributed advert rather than the
|
||||
denial of service the summaries lead with. `cargo audit` now reports
|
||||
no vulnerability, against twelve before. Four warnings remain that no
|
||||
version move fixes.
|
||||
- Every GitHub Action reference is now pinned to a commit SHA. None of
|
||||
the sixty-six was pinned before, including the jobs holding the AUR
|
||||
deploy key, the jobs with release write scope, and the packaging jobs
|
||||
that run with a signing key in the environment. Sixty-two are full
|
||||
SHAs; four are justified in one place, since two actions read the tool
|
||||
to install from the ref name itself. The sharper hole was not the
|
||||
tags: the OpenWrt workflow fetched a helper binary and a toolchain
|
||||
from release URLs with no verification at all, in two jobs holding a
|
||||
signing key. Both downloads now check a per-architecture pinned
|
||||
SHA-256.
|
||||
|
||||
### Limits, provenance checks and fail-closed defaults
|
||||
|
||||
Nineteen fixes harden a node against a party that can reach it but has
|
||||
not been admitted to it. Every claim behind them was assessed and then
|
||||
re-read by a separate reviewer briefed to refute it, and only what
|
||||
survived that pass is here. **None changes the wire format**, and none
|
||||
needs configuration.
|
||||
|
||||
They fall into four shapes.
|
||||
|
||||
**Tables that an unauthenticated party could grow.** The established
|
||||
session table had no population cap and now defaults to 1024, tunable
|
||||
with `node.limits.max_sessions`. The UDP transport's DNS cache grew one
|
||||
entry per hostname ever dialed and is now bounded at 256 with eviction.
|
||||
The Ethernet discovery buffer deduplicated beacons with a full scan and
|
||||
had no cap; it is now a map bounded at 1024 distinct MACs. The lookup
|
||||
dedup cache was fail-closed at its bound, so a flood stopped every
|
||||
lookup transiting the node; it now evicts instead of refusing.
|
||||
|
||||
**State an unauthenticated packet could change.** A relay-returned Nostr
|
||||
advert was cached without checking that the peer it named had signed it.
|
||||
A lookup response was acted on with no correlation against a lookup this
|
||||
node had issued. A STUN binding response was accepted from any source. A
|
||||
NAT punch packet was accepted from any address whose digest matched.
|
||||
Each now checks the thing that binds it.
|
||||
|
||||
**Denial paths reachable from off the path.** An epoch-mismatch `msg1`
|
||||
is authentic but replayable, and accepting it tore down a working
|
||||
peering; it is now refused while that peering is still carrying
|
||||
authenticated traffic, and dampened against repetition. The
|
||||
routing-error limiter was keyed on the field the attacker chooses. The
|
||||
Nostr notify loop ran two decrypts and a signature verify per event
|
||||
ahead of any limiter. A retired FSP key epoch could be held resident
|
||||
indefinitely by a peer that kept using it.
|
||||
|
||||
**Local fail-open surfaces.** A read error on `peers.allow` or
|
||||
`peers.deny` was swallowed and published an empty ACL, which took a
|
||||
strict allowlist node to admitting everyone; the last good policy is now
|
||||
held and retried. The control socket and its parent directory were
|
||||
created under the ambient umask and only tightened afterwards. The DNS
|
||||
mesh-interface filter was keyed on the configured TUN name and so had
|
||||
never run on macOS or FreeBSD.
|
||||
|
||||
Some findings from the same review pass are not addressed here. Where a
|
||||
fix requires a wire-format change it is not a candidate for the 0.4.x
|
||||
line at all, which takes none; that work belongs to a later release.
|
||||
`SECURITY.md` sets out the trust model this protocol assumes, and it is
|
||||
worth reading if you are deciding how far to rely on a mesh whose
|
||||
membership you do not control.
|
||||
|
||||
Two portability defects are fixed alongside them: a Windows build
|
||||
failure and a set of Windows and macOS unit-test failures. Both were
|
||||
caught by CI on those platforms rather than by review, and the coverage
|
||||
gap that let them through is recorded at the sites.
|
||||
|
||||
### A documented claim that was wrong
|
||||
|
||||
The security reference named both Noise patterns unqualified, which told
|
||||
anyone auditing the stack against the Noise specification that the
|
||||
construction was standard. It is not, in one respect: the handshake AEAD
|
||||
passes an empty associated-data field where standard Noise
|
||||
`EncryptAndHash` uses the handshake hash. Domain separation and
|
||||
Diffie-Hellman binding survive through the chaining key; transcript
|
||||
binding is the property actually absent. Nothing in the daemon reads the
|
||||
handshake hash, so no shipped behaviour rests on it, but anything later
|
||||
built on it (channel binding, an exporter, cookie binding) would
|
||||
silently not work. The reference now says so.
|
||||
|
||||
## Upgrade notes
|
||||
|
||||
This is a drop-in upgrade from v0.4.0 with no wire format change, no
|
||||
config migration, and no coordinated restart. Upgrade nodes in whatever
|
||||
order you like.
|
||||
There is no wire format change and no coordinated restart. Nodes can be
|
||||
upgraded one at a time in any order. **One thing must be done before you
|
||||
upgrade, not after**, because it is a start-time failure rather than a
|
||||
degradation.
|
||||
|
||||
Two things to do rather than assume:
|
||||
### Check two configuration relations before upgrading
|
||||
|
||||
1. If you monitor `parent_switched`, move to `parent_switches` before
|
||||
upgrading, or your dashboards will go blank rather than error.
|
||||
2. During the rolling window, expect upgraded and not-yet-upgraded nodes
|
||||
to potentially disagree about mesh size, per the FPR cap section above.
|
||||
This is expected and self-resolves. Do not chase it as a bug unless it
|
||||
persists after every node reports `0.4.1`.
|
||||
Two configuration shapes that loaded in v0.4.1 are now rejected at
|
||||
config validation. A node carrying either will not start after the
|
||||
package upgrade. Both were settings that looked like they disabled
|
||||
something and in fact made it fire continuously, so a rejection is the
|
||||
correct behaviour, but it arrives at the least convenient moment if you
|
||||
meet it for the first time on a restart.
|
||||
|
||||
If you have pinned `node.bloom.max_inbound_fpr` explicitly in your config,
|
||||
your setting is honored and nothing changes for you. The change only
|
||||
affects nodes taking the default.
|
||||
Check your config file before you upgrade:
|
||||
|
||||
Downgrading to v0.4.0 is supported and needs no special handling.
|
||||
```bash
|
||||
grep -nE 'after_messages|after_secs|signal_ttl_secs|replay_window_secs' \
|
||||
/etc/fips/fips.yaml
|
||||
```
|
||||
|
||||
## Getting v0.4.1
|
||||
On macOS the file is at `/usr/local/etc/fips/fips.yaml`.
|
||||
|
||||
**1. `node.rekey.after_messages` must be at least 1.** Zero makes the
|
||||
message-count arm true on every poll, because the trigger compares with
|
||||
greater-or-equal, so a node rekeyed on sight rather than never. The
|
||||
default is 65536. If you set it to 0 intending to disable the arm, use a
|
||||
very large value instead; there is no upper bound.
|
||||
|
||||
**2. `node.rekey.after_secs` must be greater than 15**, the per-session
|
||||
rekey jitter. 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. The
|
||||
default is 120. Both rekey checks run whether or not `node.rekey.enabled`
|
||||
is true, so turning rekey on later cannot surface the error at a
|
||||
surprising moment.
|
||||
|
||||
**3. `node.discovery.nostr.signal_ttl_secs` plus 120 must be less than
|
||||
`node.discovery.nostr.replay_window_secs`.** A traversal signal is
|
||||
acceptable over its TTL plus 60s of clock-skew grace on each side, and
|
||||
that span has to stay strictly inside the replay window, or a session id
|
||||
evicted from the replay cache on expiry is still fresh enough to be
|
||||
accepted a second time. The relation was documented but unenforced, so
|
||||
raising the TTL past 180s silently voided it. The shipped defaults, a
|
||||
TTL of 120 against a window of 300, are unaffected. The error names the
|
||||
concrete floor for `replay_window_secs`, so if you hit it on a test
|
||||
start the fix is in the message.
|
||||
|
||||
The safest sequence is to run that grep on every node's config first,
|
||||
correct anything that trips one of the three rules, and only then
|
||||
upgrade.
|
||||
|
||||
### If you use `fips` as a library
|
||||
|
||||
**Binaries are unaffected. Skip this section unless you build against
|
||||
the `fips` crate.**
|
||||
|
||||
Four public types gained a `Drop` implementation as part of clearing key
|
||||
material at end of scope: `Identity`, `ResolvedIdentity`,
|
||||
`IdentityConfig` and `HandshakeState`. A type that implements `Drop`
|
||||
cannot have its fields moved out, so this is source-breaking for a
|
||||
consumer of the library crate even though nothing about the shipped
|
||||
binaries changes.
|
||||
|
||||
`IdentityConfig` is the one most likely to be reached in practice,
|
||||
because it hangs off the public `Config` as `node.identity`. Code that
|
||||
moved the nsec out of a configuration value no longer compiles. The fix
|
||||
is `Option::take` on the field rather than moving the value out.
|
||||
|
||||
This is a source break in a patch release, which semantic versioning
|
||||
does not sanction. It ships anyway because the alternative was holding a
|
||||
security fix for the next minor, and because the crate is not published
|
||||
to a registry, so the reachable population is small.
|
||||
|
||||
### During and after a rolling upgrade
|
||||
|
||||
- `TtlExhausted` charges at a different node than it did, so its
|
||||
distribution shifts by one hop while the mesh is mixed. This settles
|
||||
once every node reports `0.4.2`.
|
||||
- If you scrape the control socket, expect `show_routing` and
|
||||
`show_status` to carry new counters. An older `fipsctl` or `fipstop`
|
||||
gains unknown fields rather than losing known ones.
|
||||
- On macOS, move `peers.allow`, `peers.deny`, `hosts`, `fips.yaml` and
|
||||
`fips.key` from `/etc/fips/` to `/usr/local/etc/fips/`. The daemon
|
||||
warns once at startup if it finds any of them only at the old
|
||||
location, and it will adopt a key stranded there rather than
|
||||
generating a new identity, but the warning is the signal to move them
|
||||
rather than to leave them.
|
||||
- A mesh whose peers advertise a legitimately narrow path MTU should be
|
||||
watched once after the upgrade. Locally derived values are exempt from
|
||||
the new floor at both the seed and the clamp, so a narrow link is
|
||||
expected to adapt as before, but that exemption is asserted in the
|
||||
code rather than proven by a test that drives a genuinely narrow path.
|
||||
|
||||
Downgrading to v0.4.1 is supported. A config corrected for the three
|
||||
rules above still loads on v0.4.1, so the correction does not have to be
|
||||
reverted.
|
||||
|
||||
## Getting v0.4.2
|
||||
|
||||
- **Linux x86_64 / aarch64**: `.deb` and tarball at the
|
||||
[v0.4.1 release page](https://github.com/jmcorgan/fips/releases/tag/v0.4.1).
|
||||
[v0.4.2 release page](https://github.com/jmcorgan/fips/releases/tag/v0.4.2).
|
||||
- **Arch Linux**: `fips` from the AUR.
|
||||
- **macOS**: `.pkg` at the v0.4.1 release page.
|
||||
- **Windows**: ZIP at the v0.4.1 release page.
|
||||
- **macOS**: `.pkg` at the v0.4.2 release page.
|
||||
- **Windows**: ZIP at the v0.4.2 release page.
|
||||
- **OpenWrt**: `.ipk` (OpenWrt 24.x and earlier) or `.apk` (OpenWrt 25+)
|
||||
at the v0.4.1 release page.
|
||||
- **From source**: `cargo build --release` from a checkout of the v0.4.1
|
||||
at the v0.4.2 release page.
|
||||
- **From source**: `cargo build --release` from a checkout of the v0.4.2
|
||||
tag (Rust 1.94.1 per `rust-toolchain.toml`; `libclang-dev` is a
|
||||
required Linux build prerequisite).
|
||||
- **Nix / NixOS**: `nix build .#fips` from a checkout of the v0.4.1 tag
|
||||
builds the binaries from source with the pinned toolchain and no manual
|
||||
prerequisites (see the Nix section of `packaging/README.md`).
|
||||
- **Nix / NixOS**: `nix build .#fips` from a checkout of the v0.4.2 tag
|
||||
builds the binaries from source with the pinned toolchain and no
|
||||
manual prerequisites (see the Nix section of `packaging/README.md`).
|
||||
|
||||
The full per-commit changelog lives in
|
||||
[`CHANGELOG.md`](../../CHANGELOG.md). Issues and discussion at
|
||||
[github.com/jmcorgan/fips](https://github.com/jmcorgan/fips).
|
||||
|
||||
Security reports have a private channel as of this release; see
|
||||
[`SECURITY.md`](../../SECURITY.md).
|
||||
|
||||
## Contributors
|
||||
|
||||
Thanks to everyone who contributed code, packaging work, bug reports, or
|
||||
reviews to this release.
|
||||
|
||||
- [@jcorgan](https://github.com/jmcorgan): release shepherd, spanning-tree
|
||||
and discovery fixes, bloom and identity performance work, antipoison cap
|
||||
change, and testing.
|
||||
- [@jmcorgan](https://github.com/jmcorgan) (Johnathan Corgan): release
|
||||
shepherd; the session and handshake authentication work, path MTU
|
||||
bounding, key material protection and clearing, gateway DNS answer
|
||||
validation, Action pinning and the dependency refresh, the traversal
|
||||
clock fix, spanning-tree and rate-limiting work, and the test harness.
|
||||
- [@sh1ftred](https://github.com/sh1ftred): the macOS install layout
|
||||
fix, so config, ACL and identity paths follow the platform packaging
|
||||
([#132](https://github.com/jmcorgan/fips/pull/132)). First
|
||||
contribution to FIPS.
|
||||
|
||||
Bug reports and reviews that shaped this release:
|
||||
|
||||
- [@Theleifless](https://github.com/Theleifless): reported
|
||||
[#128](https://github.com/jmcorgan/fips/issues/128), NAT traversal
|
||||
breaking after the host sleeps.
|
||||
- [@ngmisl](https://github.com/ngmisl): filed
|
||||
[#137](https://github.com/jmcorgan/fips/issues/137), the security
|
||||
review most of this release's security work answers.
|
||||
|
||||
@@ -0,0 +1,676 @@
|
||||
# FIPS v0.4.2
|
||||
|
||||
**Released**: 2026-08-25
|
||||
|
||||
v0.4.2 is a maintenance release on the v0.4.x line, and the largest one
|
||||
this line has carried: 144 commits since v0.4.1. Most of it is security
|
||||
work. It closes several paths by which a party that could merely reach a
|
||||
node could take its sessions down, have its traffic attributed to
|
||||
another node, or steer that node's path MTU; it bounds a set of tables
|
||||
an unauthenticated party could grow without limit; it closes the
|
||||
fail-open cases where a local error widened what a node accepted; it
|
||||
fixes NAT traversal in two places where it was simply not working; and
|
||||
it protects private key material on disk and clears it in memory. There
|
||||
is no wire format change.
|
||||
|
||||
The new configuration surface is small and optional: three admission and
|
||||
rate-limiting keys, each with a default that needs no action. The
|
||||
repository also gains a `SECURITY.md`, so someone with a finding no
|
||||
longer has to guess at an address or open a public issue.
|
||||
|
||||
v0.4.2 is wire-compatible with v0.4.1. No frame gains, loses, or resizes
|
||||
a field, so a mixed mesh works and nodes can be upgraded one at a time
|
||||
with no coordinated restart. Three changes narrow what a node accepts,
|
||||
or change how it acts on a field it already read: the session datagram
|
||||
hop limit, the path MTU floor, and routing-signal admission. Those three
|
||||
are what the interop gate is pointed at deliberately, rather than at
|
||||
connectivity alone. Compatibility is the release's intent and what that
|
||||
gate checks; it is not a claim that every mixed pairing was exercised.
|
||||
|
||||
**Read the upgrade notes before you start.** Two configuration shapes
|
||||
that loaded in v0.4.1 now refuse to start.
|
||||
|
||||
## At a glance
|
||||
|
||||
### Before you upgrade
|
||||
|
||||
- Two configuration shapes now fail to load: a `node.rekey` interval
|
||||
that fires the trigger continuously, and a traversal signal TTL too
|
||||
large for the replay window. Both are start-time failures, so a node
|
||||
carrying either will not come back after a package upgrade.
|
||||
- macOS installs now read the ACL, hosts, config and identity files from
|
||||
`/usr/local/etc/fips/`, which is where the packaging puts them.
|
||||
|
||||
### Security
|
||||
|
||||
- Six paths by which an unauthenticated or misattributed packet changed
|
||||
a node's session state are closed. One of them terminated the daemon.
|
||||
- A remote party can no longer drive a destination's path MTU to zero,
|
||||
or aim a node's UDP punch packets at addresses of its choosing.
|
||||
- Nineteen fixes harden a node against a party that can reach it but is
|
||||
not an admitted peer: unbounded tables, unauthenticated writes and
|
||||
fail-open paths. None changes the wire format and none needs action.
|
||||
- Private keys are no longer written through a symlink, an existing
|
||||
`fips.key` has its mode retightened on every write, and a failed key
|
||||
write no longer leaves a node silently running an ephemeral identity.
|
||||
|
||||
### Connectivity and performance
|
||||
|
||||
- NAT traversal works in two deployments where it did not: a public node
|
||||
in open mode, and any host that suspends.
|
||||
- Roughly 35 ms per tick comes back at 240 peers, and multi-second
|
||||
rx-loop stalls during peer retry are gone.
|
||||
|
||||
### New configuration, all optional
|
||||
|
||||
- `node.rate_limit.session_setup_burst` / `_rate`,
|
||||
`node.rate_limit.established_handshake_burst` / `_rate`,
|
||||
`node.discovery.nostr.max_concurrent_offers_per_npub`, and
|
||||
`node.limits.max_sessions` (default 1024). Each default needs no
|
||||
action.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `cargo audit` reports no vulnerability, against twelve before, and
|
||||
every GitHub Action is pinned to a commit SHA.
|
||||
|
||||
## A note on the security content
|
||||
|
||||
Most of this release is security work, and most of that work began with
|
||||
reviews the project did not commission. Over the past month a number of
|
||||
unsolicited security reviews have arrived, and they share a character:
|
||||
they are driven by current frontier language models, their authors say
|
||||
so, and they arrive as specific, carefully written reports citing the
|
||||
code they describe rather than as vague claims.
|
||||
|
||||
The findings have been legitimate. Not every one survived a second
|
||||
reading, and several described documented behaviour as a defect. But
|
||||
enough held up under adversarial re-reading that treating this class of
|
||||
report as noise would have been a mistake, and a substantial part of
|
||||
what this release fixes was found that way, including issues in code
|
||||
that had been reviewed before.
|
||||
|
||||
None of it has been reported active in a deployment. What these reviews
|
||||
have produced are reachable defects rather than observed incidents, and
|
||||
finding them at that stage is the outcome everyone would choose.
|
||||
|
||||
This looks like a broader shift rather than something particular to this
|
||||
project. The cost of a competent first pass over an unfamiliar codebase
|
||||
has fallen sharply, and open source is benefiting from it: small
|
||||
projects are now getting the kind of attention that was previously
|
||||
reserved for large ones. We welcome it, and we would rather receive a
|
||||
report of this kind than not. `SECURITY.md` describes how to send one.
|
||||
The most useful reports are the ones that say plainly which parts were
|
||||
machine-generated and which were verified by a person, because that is
|
||||
the difference between a lead and a finding, and we assess the two
|
||||
differently.
|
||||
|
||||
## Behavior changes worth flagging
|
||||
|
||||
### The session datagram hop limit now follows IP semantics
|
||||
|
||||
Delivery to the addressed node is no longer gated on the hop limit, and
|
||||
a forwarder decrements before deciding rather than after. Two cases
|
||||
change on a deployed line:
|
||||
|
||||
| Case | v0.4.1 | v0.4.2 |
|
||||
| ---- | ------ | ------ |
|
||||
| Addressed to this node, hop limit 0 | dropped | delivered |
|
||||
| Transit datagram, hop limit 1 | forwarded at 0 | dropped here |
|
||||
|
||||
The reachable radius is unchanged, because the two behaviors compensate
|
||||
exactly: a path of `h` links still delivers for any source hop limit of
|
||||
`h` or more. During a rolling upgrade no version mix delivers less far,
|
||||
and an unupgraded forwarder feeding an upgraded destination delivers one
|
||||
hop further than either version does on its own.
|
||||
|
||||
What an operator will see move is the counter. `TtlExhausted` now
|
||||
charges at the node that makes the decision rather than at the hop after
|
||||
it, so its distribution across a mixed mesh shifts by one hop while the
|
||||
upgrade is in progress. That is expected and is not a loss of traffic.
|
||||
|
||||
### `node.rekey.enabled` governs periodic rekey only
|
||||
|
||||
This is a correction to what the setting has always meant rather than a
|
||||
new field. `enabled` controls whether this node *initiates* periodic
|
||||
rekey. A rekey a peer drives is still answered when it is off, and two
|
||||
things that used to sit behind the same gate no longer do: the session
|
||||
drain sweep and the cut-over that retires an old key epoch now run
|
||||
either way. A node with rekey disabled previously held superseded keys
|
||||
for the life of the session.
|
||||
|
||||
### New admission defaults you may feel
|
||||
|
||||
None of these needs configuration, and none changes an existing key's
|
||||
value. They are new bounds where there was none.
|
||||
|
||||
- `node.discovery.nostr.max_concurrent_offers_per_npub` defaults to 4.
|
||||
It sits inside `max_concurrent_incoming_offers` (16), which remains
|
||||
the outer bound, so a value above that is inert.
|
||||
- `node.rate_limit.session_setup_burst` (64) and `session_setup_rate`
|
||||
(16.0) meter inbound FSP session setup per authenticated link peer. A
|
||||
legitimate peer arriving over the same link as a flooding one shares
|
||||
that link's bucket, so establishment behind a flooded neighbour is
|
||||
refused until it refills.
|
||||
- `node.rate_limit.established_handshake_burst` and `_rate` are optional
|
||||
and normally omitted; the bucket is then derived from
|
||||
`node.limits.max_peers`, `node.rekey.after_secs` and
|
||||
`node.rate_limit.handshake_max_resends`, so raising the peer limit
|
||||
sizes it automatically.
|
||||
- An accepted inbound TCP or onion connection now has a deadline for its
|
||||
first frame. This is a module constant, not a configuration key.
|
||||
- A remote-supplied path MTU below an actionable minimum is ignored
|
||||
rather than applied or stored. Locally derived MTUs are exempt at both
|
||||
the seed and the TCP MSS clamp, so a genuinely narrow link, which the
|
||||
mesh does use, still adapts.
|
||||
|
||||
### Control socket snapshots carry new fields
|
||||
|
||||
`show_routing` and `show_status` gained counters this cycle:
|
||||
`warm_malformed_packets` and `warm_malformed_bytes`, and the four
|
||||
error-signal counters `unbound_coords`, `unbound_broken`, `unbound_mtu`
|
||||
and `unbound_forged`. An older `fipsctl` or `fipstop` reading a newer
|
||||
daemon gains unknown fields rather than losing known ones. If you scrape
|
||||
those snapshots, expect additions, not removals. Two other new counters,
|
||||
the framing `payload_len_mismatch` and the setup-message refusal
|
||||
counters, are not yet readable over the control socket.
|
||||
|
||||
### The bloom announce sweep changes where its cost sits
|
||||
|
||||
Peer bloom filters are now computed for every recipient in one union
|
||||
sweep instead of being rebuilt per recipient. The result is exactly
|
||||
equal, not approximately: merging is a bytewise OR. 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 costs about twice what it did. Break-even is around three
|
||||
ready peers, so a small mesh pays slightly more and a large one pays a
|
||||
great deal less. Cadence, the debounce, the sequence rule and the
|
||||
fill-ratio cap are unchanged.
|
||||
|
||||
## Notable bug fixes
|
||||
|
||||
Every item here is a fix for a defect that shipped in v0.4.1. Fixes for
|
||||
defects introduced and resolved inside this cycle are in the CHANGELOG
|
||||
and are not repeated here. The CHANGELOG is the complete record; this
|
||||
section is a selection.
|
||||
|
||||
### Session and handshake authentication
|
||||
|
||||
This is the release's centre of gravity. Six paths are closed, each of
|
||||
which let a packet that authenticated nothing change a node's session
|
||||
state.
|
||||
|
||||
- **A truncated inner payload terminated the daemon.** A
|
||||
`SessionDatagram` whose inner FSP payload was 4 to 11 bytes, with
|
||||
phase 0x0 and the Coords Present flag set, indexed past the end of a
|
||||
slice on the coordinate-cache warm path. The receive loop is the
|
||||
process's main future, so the panic took the daemon down rather than a
|
||||
task, and under the packaged systemd unit the node restarted into the
|
||||
same frame. Any peer past a link handshake could send it, and
|
||||
admission is default-open.
|
||||
- **An unauthenticated setup message could hold a session down.** With
|
||||
`node.rekey.enabled` false, a setup message naming an established peer
|
||||
replaced that peer's session outright, discarding the live keys. The
|
||||
message carries no authenticator and its source address is an envelope
|
||||
field the sender picks. The established case now arms a handshake
|
||||
beside the running session and adopts new keys only after a msg3 whose
|
||||
authenticated static key matches the one the session was opened with.
|
||||
- **A forged `SessionAck` cancelled an in-flight initiation**, and an
|
||||
unauthenticated msg3 discarded a completed key epoch. Both took 57
|
||||
bytes of the right length from anyone who could reach the node, and
|
||||
both were repeatable. The setup path is now also rate limited, keyed
|
||||
on the authenticated link peer the datagram arrived over rather than
|
||||
on the address the sender claims.
|
||||
- **A peer could complete a genuine handshake under another node's
|
||||
address.** The responder recorded a session under the source address
|
||||
in the datagram without checking it against the static key it had just
|
||||
authenticated, so the identity cache, the session map and the
|
||||
reconstructed mesh IPv6 all attributed that traffic to the node it
|
||||
named. The address is now derived from the authenticated key, on both
|
||||
the initial and the rekey path.
|
||||
- **Routing signals were acted on for any address.** `CoordsRequired`,
|
||||
`PathBroken` and `MtuExceeded` carry no end-to-end authentication, and
|
||||
a node applied their effects for any destination they named. They are
|
||||
now refused unless this node has itself bound that destination, and
|
||||
each refusal is counted. Signals from a genuine on-path forwarder at
|
||||
any distance are unaffected.
|
||||
- **The established-address waiver admitted the wrong party.** A
|
||||
transport with `accept_connections` false still admits an inbound msg1
|
||||
sourced from an established peer's address, so a peer re-handshaking
|
||||
after a restart is not locked out. Nothing checked that the sender was
|
||||
that peer, so any off-path party sourcing from the address obtained a
|
||||
full link handshake from a node configured to accept none. The
|
||||
handshake is now dropped once the key exchange reveals a static key
|
||||
that does not belong to the identity owning that address.
|
||||
|
||||
A frame whose declared payload length disagrees with the length that
|
||||
arrived is also now dropped at the single dispatch point, before that
|
||||
field can be used as a parsing input. This closes no known defect: on
|
||||
the stream transports the comparison holds by construction, and on the
|
||||
datagram transports a short frame already failed the AEAD tag. What
|
||||
changes is which reason it is dropped for.
|
||||
|
||||
### NAT traversal and Nostr discovery
|
||||
|
||||
- **Traversal was non-functional on a public node in open mode.** A
|
||||
signal is addressed to the merge of the peer's inbox relays, the
|
||||
relays its advert nominates, and our own, but the send was rejected
|
||||
outright if any single URL in that merge was outside the client pool
|
||||
built at startup. One unconfigured relay killed the whole attempt,
|
||||
including the sends to relays both sides shared. Measured in an
|
||||
open-mode window: 309 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 now on the
|
||||
normalized relay URL, so a trailing slash or a different host case
|
||||
does not discard a relay that is in fact configured.
|
||||
- **Traversal broke permanently after the host suspended.** The
|
||||
traversal clock cached a Unix timestamp at startup and advanced it
|
||||
with a monotonic instant, which does not tick while a machine is
|
||||
asleep, so the daemon's idea of the time trailed real time by the
|
||||
suspend duration for the rest of the process lifetime. Every
|
||||
expiration it published was already in the past, relays dropped the
|
||||
offers, and traversal stayed dead until restart. The clock now reads
|
||||
the wall clock on every call. A laptop is where this is easiest to
|
||||
hit, but any host that suspends or hibernates was affected. Reported
|
||||
in [#128](https://github.com/jmcorgan/fips/issues/128).
|
||||
- **A node could be aimed at third parties.** A rendezvous-enabled node
|
||||
punched every address a signed offer named, with no limit on how many
|
||||
one offer could carry, so any npub could have it emit a burst of UDP
|
||||
packets carrying its own source address at loopback, link-local,
|
||||
multicast, broadcast, unspecified or CGNAT addresses. Never-routable
|
||||
ranges are now rejected, IPv4-mapped forms are canonicalized first so
|
||||
they cannot slip past, port 0 is dropped, private-range candidates are
|
||||
punched only when they share a /24 with one of our own addresses, and
|
||||
the planned list is capped at eight. Each planning attempt logs what
|
||||
it declined and why.
|
||||
- **A future-dated traversal signal was accepted as strictly fresh.**
|
||||
The freshness check measured age with a saturating subtraction, which
|
||||
yields zero for any timestamp ahead of the local clock, and nothing
|
||||
else bounded the issue time from above. Forward-dating is now
|
||||
tolerated only to the same 60s of clock skew already allowed in the
|
||||
other direction, and a declared expiry is no longer trusted past the
|
||||
issue time plus the configured TTL.
|
||||
- **One sender could hold every inbound offer slot.** Admission took a
|
||||
permit from a single pool before any identity check, with the sender's
|
||||
npub used only as a log field. Admission now takes a per-npub permit
|
||||
and a global permit together. This does not make the pool
|
||||
inexhaustible: Nostr identities cost nothing to generate, so four
|
||||
throwaway npubs still saturate the shipped 16-slot pool at an
|
||||
unchanged total offer rate. What it buys is that one identity can no
|
||||
longer do it alone, and that the two refusals are distinguishable in
|
||||
the log.
|
||||
|
||||
### Path MTU
|
||||
|
||||
A single `MtuExceeded` carrying a very small value drove a session's
|
||||
path MTU to zero, after which every packet to that destination was
|
||||
answered with an ICMPv6 Packet Too Big instead of being sent: a
|
||||
blackhole lasting until the daemon restarted. The same value reached the
|
||||
SYN-time TCP MSS clamp, where anything at or below 137 saturates the
|
||||
segment size to zero. The `path_mtu` field is an unsigned per-hop
|
||||
annotation carried outside the signed proof, and `MtuExceeded` and
|
||||
`PathBroken` arrive unencrypted with no sender check, so any forwarder,
|
||||
or anyone able to reach the node, could lower it.
|
||||
|
||||
Remote values below an actionable minimum are now ignored at the three
|
||||
places a remote value is acted on, each with its own warning and
|
||||
counter, and the per-destination cache has a way back: an entry is
|
||||
released on a `PathBroken` report, on session idle expiry, and on
|
||||
handshake timeout, with the local link MTU reseeded in its place.
|
||||
Entries written by the discovery lookup carrier age out on a deadline of
|
||||
their own, because a destination this node never opens a session with
|
||||
reaches none of those three routes. Without it, one response carrying a
|
||||
floor value pinned that destination's clamp until the daemon restarted.
|
||||
|
||||
### Inbound connection slots and rekey admission
|
||||
|
||||
- **An unauthenticated remote could lock out inbound peering by staying
|
||||
silent.** The peer cap was tested at accept, with no read in between,
|
||||
and the frame reader's reads carried no deadline. Pool keys are
|
||||
`ip:port`, so N sockets from one address took N slots, and at the 256
|
||||
default that closed the node to new peers for as long as the sockets
|
||||
stayed open. The first frame now has a deadline, the onion listener
|
||||
gets the same treatment, and the handshake reaper now closes the
|
||||
transport connection it used to forget. This does not close the whole
|
||||
case: a peer that sends one well-formed frame and then goes silent
|
||||
still holds its slot.
|
||||
- **Rekey traffic was being refused on busy nodes, silently.** Rekey and
|
||||
restart msg1 on an established link competed with stranger admission
|
||||
for one shared token bucket. Measured on a field node at roughly 245
|
||||
peers: 8753 msg1 refused in 25 minutes, with 159 of the 201 distinct
|
||||
sources being peers it already held sessions with. Nothing errored and
|
||||
no session dropped, so the only symptom was a flat `rekey_armed`.
|
||||
Inbound msg1 is now classified before it is limited and draws on its
|
||||
own bucket. Nodes upgrade with no config change, and the
|
||||
`Msg1 rate limited` line now says which limb refused.
|
||||
|
||||
### Identity and key files on disk
|
||||
|
||||
- **A private key write followed a symlink**, because the single write
|
||||
path opened with create and truncate and no `O_NOFOLLOW`. Both writers
|
||||
now share an open helper that carries it.
|
||||
- **An existing `fips.key` kept a loose mode forever.** The mode was
|
||||
supplied only through `open(2)`, which the kernel honours on creation
|
||||
and ignores otherwise, so a key file at 0644 stayed 0644 through every
|
||||
rewrite. That needs no attacker: one `chmod`, or a restore that did
|
||||
not preserve modes, leaves the key readable indefinitely. The mode is
|
||||
now applied to the open descriptor before any secret bytes are
|
||||
written. On Windows neither protection applies and the file inherits
|
||||
the parent directory's ACLs; that exclusion is deliberate.
|
||||
- **A failed key write left a node running an ephemeral identity in
|
||||
silence.** Six write results in the identity path were discarded, and
|
||||
the sharpest was in `persistent` mode: a failed write to `fips.key`
|
||||
fell through to an ephemeral identity with no message, so a node asked
|
||||
for a stable identity changed its npub, its routing address and its
|
||||
mesh IPv6 on every start, and nothing said so. All six now report.
|
||||
- **Key material is now cleared when it goes out of scope.** Nothing in
|
||||
the crate erased a key before this. Clearing now covers the session
|
||||
and handshake keys, the identity keypair, the temporary copies the
|
||||
elliptic-curve operations make, encoded secrets, and the private key
|
||||
on its way through configuration, including the config file's text,
|
||||
since `node.identity.nsec` is read straight out of it. This clears the
|
||||
copies the crate owns, not every copy that ever existed: the secp256k1
|
||||
key types are copyable, and the hash, key-derivation and cached cipher
|
||||
states of the pinned libraries offer no clearing route. Reading the
|
||||
residue needs access to the process's memory, or to a core dump or
|
||||
swap image of it. **This carries a source-breaking change for library
|
||||
consumers; see the upgrade notes.**
|
||||
|
||||
### Gateway DNS answers
|
||||
|
||||
The gateway's DNS forwarder accepted whatever datagram arrived on its
|
||||
upstream socket. The upstream query reused the client's own transaction
|
||||
ID, the socket was wildcard-bound and never connected, the receive
|
||||
discarded the sender, neither the response ID nor the question was
|
||||
compared against what was asked, and the returned address was not
|
||||
checked against the mesh prefix. Because the extracted address is
|
||||
installed as a DNAT rule with no interface constraint, a forged answer
|
||||
redirected traffic rather than only poisoning a lookup.
|
||||
|
||||
The query now carries a random transaction ID, the socket is connected
|
||||
so the kernel drops foreign sources, a response must match on ID,
|
||||
question and type, and the address goes through the validating parser
|
||||
before any allocation. One deliberate behaviour change: validation sits
|
||||
before the rcode check, so an upstream answering FORMERR or REFUSED with
|
||||
an empty question section now yields SERVFAIL rather than having its
|
||||
rcode relayed. Checking after the rcode would admit a forged NXDOMAIN.
|
||||
|
||||
### macOS install layout
|
||||
|
||||
On macOS the daemon and `fipsctl` read `/etc/fips/`, a directory the
|
||||
macOS packaging does not create, while the packaging installs to
|
||||
`/usr/local/etc/fips/`. The effect was silent in the worst way: a
|
||||
populated `peers.deny` reported `effective_mode: "default_open"` with
|
||||
`enforcement_active: false` through `fipsctl acl show`, host-file
|
||||
aliases went unloaded, and `fipsctl keygen` wrote an identity where the
|
||||
daemon never read it, so the node kept an ephemeral one. The default
|
||||
paths now follow the platform's packaging, the system config search path
|
||||
includes `/usr/local/etc/fips/fips.yaml`, and a key stranded at the
|
||||
legacy path is adopted with a warning rather than a fresh identity being
|
||||
generated. Linux and Windows behavior is unchanged.
|
||||
|
||||
**macOS users with existing files in `/etc/fips/` should move them to
|
||||
`/usr/local/etc/fips/`.**
|
||||
|
||||
### Robustness under adverse local conditions
|
||||
|
||||
- **A failed log write could panic the thread or task that logged.** The
|
||||
subscriber reported its own internal errors through `eprintln!`, which
|
||||
panics when stderr has also failed, and the shipped supervisor
|
||||
configurations make that one condition rather than two: the macOS
|
||||
plist points both standard streams at one file, and the systemd units
|
||||
route both to journald, so one full disk fails both sinks together. In
|
||||
the daemon the casualty was a crypto worker, which takes its share of
|
||||
the peer space with it permanently. In `fips-gateway` it was a spawned
|
||||
task: the DNS resolver, the control accept loop or the pool tick, none
|
||||
of which is observed until shutdown, so the process kept running and
|
||||
reporting healthy with mesh name resolution or lease expiry and NAT
|
||||
cleanup stopped.
|
||||
- **Flap dampening could engage only once in a node's lifetime.** The
|
||||
arming check tested whether a deadline had ever been set rather than
|
||||
whether one was still in effect, so after the first episode a node in
|
||||
a second flap storm went on switching parents under hold-down alone,
|
||||
and neither the `flap_dampened` counter nor the warning fired again.
|
||||
Hold-down was unaffected throughout, which is why the practical cost
|
||||
at shipped settings was lost visibility rather than unrestrained
|
||||
flapping. Separately, a `node.tree.flap_dampening_secs` large enough
|
||||
to overflow the monotonic clock is now capped at one year instead of
|
||||
panicking the node when dampening engages.
|
||||
|
||||
### Supply chain
|
||||
|
||||
- The dependency lockfile moves past a set of advisories against the
|
||||
pinned `nostr` 0.44.3 and `nostr-relay-pool` 0.44.1, both of which
|
||||
were also yanked. The ones that matter here are the relay-pool
|
||||
advisories describing forged events bypassing signature validation and
|
||||
unverified relay events being processed: that is the path a node
|
||||
learns peer adverts on, and it performs no independent verification of
|
||||
its own, so the exposure was a misattributed advert rather than the
|
||||
denial of service the summaries lead with. `cargo audit` now reports
|
||||
no vulnerability, against twelve before. Four warnings remain that no
|
||||
version move fixes.
|
||||
- Every GitHub Action reference is now pinned to a commit SHA. None of
|
||||
the sixty-six was pinned before, including the jobs holding the AUR
|
||||
deploy key, the jobs with release write scope, and the packaging jobs
|
||||
that run with a signing key in the environment. Sixty-two are full
|
||||
SHAs; four are justified in one place, since two actions read the tool
|
||||
to install from the ref name itself. The sharper hole was not the
|
||||
tags: the OpenWrt workflow fetched a helper binary and a toolchain
|
||||
from release URLs with no verification at all, in two jobs holding a
|
||||
signing key. Both downloads now check a per-architecture pinned
|
||||
SHA-256.
|
||||
|
||||
### Limits, provenance checks and fail-closed defaults
|
||||
|
||||
Nineteen fixes harden a node against a party that can reach it but has
|
||||
not been admitted to it. Every claim behind them was assessed and then
|
||||
re-read by a separate reviewer briefed to refute it, and only what
|
||||
survived that pass is here. **None changes the wire format**, and none
|
||||
needs configuration.
|
||||
|
||||
They fall into four shapes.
|
||||
|
||||
**Tables that an unauthenticated party could grow.** The established
|
||||
session table had no population cap and now defaults to 1024, tunable
|
||||
with `node.limits.max_sessions`. The UDP transport's DNS cache grew one
|
||||
entry per hostname ever dialed and is now bounded at 256 with eviction.
|
||||
The Ethernet discovery buffer deduplicated beacons with a full scan and
|
||||
had no cap; it is now a map bounded at 1024 distinct MACs. The lookup
|
||||
dedup cache was fail-closed at its bound, so a flood stopped every
|
||||
lookup transiting the node; it now evicts instead of refusing.
|
||||
|
||||
**State an unauthenticated packet could change.** A relay-returned Nostr
|
||||
advert was cached without checking that the peer it named had signed it.
|
||||
A lookup response was acted on with no correlation against a lookup this
|
||||
node had issued. A STUN binding response was accepted from any source. A
|
||||
NAT punch packet was accepted from any address whose digest matched.
|
||||
Each now checks the thing that binds it.
|
||||
|
||||
**Denial paths reachable from off the path.** An epoch-mismatch `msg1`
|
||||
is authentic but replayable, and accepting it tore down a working
|
||||
peering; it is now refused while that peering is still carrying
|
||||
authenticated traffic, and dampened against repetition. The
|
||||
routing-error limiter was keyed on the field the attacker chooses. The
|
||||
Nostr notify loop ran two decrypts and a signature verify per event
|
||||
ahead of any limiter. A retired FSP key epoch could be held resident
|
||||
indefinitely by a peer that kept using it.
|
||||
|
||||
**Local fail-open surfaces.** A read error on `peers.allow` or
|
||||
`peers.deny` was swallowed and published an empty ACL, which took a
|
||||
strict allowlist node to admitting everyone; the last good policy is now
|
||||
held and retried. The control socket and its parent directory were
|
||||
created under the ambient umask and only tightened afterwards. The DNS
|
||||
mesh-interface filter was keyed on the configured TUN name and so had
|
||||
never run on macOS or FreeBSD.
|
||||
|
||||
Some findings from the same review pass are not addressed here. Where a
|
||||
fix requires a wire-format change it is not a candidate for the 0.4.x
|
||||
line at all, which takes none; that work belongs to a later release.
|
||||
`SECURITY.md` sets out the trust model this protocol assumes, and it is
|
||||
worth reading if you are deciding how far to rely on a mesh whose
|
||||
membership you do not control.
|
||||
|
||||
Two portability defects are fixed alongside them: a Windows build
|
||||
failure and a set of Windows and macOS unit-test failures. Both were
|
||||
caught by CI on those platforms rather than by review, and the coverage
|
||||
gap that let them through is recorded at the sites.
|
||||
|
||||
### A documented claim that was wrong
|
||||
|
||||
The security reference named both Noise patterns unqualified, which told
|
||||
anyone auditing the stack against the Noise specification that the
|
||||
construction was standard. It is not, in one respect: the handshake AEAD
|
||||
passes an empty associated-data field where standard Noise
|
||||
`EncryptAndHash` uses the handshake hash. Domain separation and
|
||||
Diffie-Hellman binding survive through the chaining key; transcript
|
||||
binding is the property actually absent. Nothing in the daemon reads the
|
||||
handshake hash, so no shipped behaviour rests on it, but anything later
|
||||
built on it (channel binding, an exporter, cookie binding) would
|
||||
silently not work. The reference now says so.
|
||||
|
||||
## Upgrade notes
|
||||
|
||||
There is no wire format change and no coordinated restart. Nodes can be
|
||||
upgraded one at a time in any order. **One thing must be done before you
|
||||
upgrade, not after**, because it is a start-time failure rather than a
|
||||
degradation.
|
||||
|
||||
### Check two configuration relations before upgrading
|
||||
|
||||
Two configuration shapes that loaded in v0.4.1 are now rejected at
|
||||
config validation. A node carrying either will not start after the
|
||||
package upgrade. Both were settings that looked like they disabled
|
||||
something and in fact made it fire continuously, so a rejection is the
|
||||
correct behaviour, but it arrives at the least convenient moment if you
|
||||
meet it for the first time on a restart.
|
||||
|
||||
Check your config file before you upgrade:
|
||||
|
||||
```bash
|
||||
grep -nE 'after_messages|after_secs|signal_ttl_secs|replay_window_secs' \
|
||||
/etc/fips/fips.yaml
|
||||
```
|
||||
|
||||
On macOS the file is at `/usr/local/etc/fips/fips.yaml`.
|
||||
|
||||
**1. `node.rekey.after_messages` must be at least 1.** Zero makes the
|
||||
message-count arm true on every poll, because the trigger compares with
|
||||
greater-or-equal, so a node rekeyed on sight rather than never. The
|
||||
default is 65536. If you set it to 0 intending to disable the arm, use a
|
||||
very large value instead; there is no upper bound.
|
||||
|
||||
**2. `node.rekey.after_secs` must be greater than 15**, the per-session
|
||||
rekey jitter. 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. The
|
||||
default is 120. Both rekey checks run whether or not `node.rekey.enabled`
|
||||
is true, so turning rekey on later cannot surface the error at a
|
||||
surprising moment.
|
||||
|
||||
**3. `node.discovery.nostr.signal_ttl_secs` plus 120 must be less than
|
||||
`node.discovery.nostr.replay_window_secs`.** A traversal signal is
|
||||
acceptable over its TTL plus 60s of clock-skew grace on each side, and
|
||||
that span has to stay strictly inside the replay window, or a session id
|
||||
evicted from the replay cache on expiry is still fresh enough to be
|
||||
accepted a second time. The relation was documented but unenforced, so
|
||||
raising the TTL past 180s silently voided it. The shipped defaults, a
|
||||
TTL of 120 against a window of 300, are unaffected. The error names the
|
||||
concrete floor for `replay_window_secs`, so if you hit it on a test
|
||||
start the fix is in the message.
|
||||
|
||||
The safest sequence is to run that grep on every node's config first,
|
||||
correct anything that trips one of the three rules, and only then
|
||||
upgrade.
|
||||
|
||||
### If you use `fips` as a library
|
||||
|
||||
**Binaries are unaffected. Skip this section unless you build against
|
||||
the `fips` crate.**
|
||||
|
||||
Four public types gained a `Drop` implementation as part of clearing key
|
||||
material at end of scope: `Identity`, `ResolvedIdentity`,
|
||||
`IdentityConfig` and `HandshakeState`. A type that implements `Drop`
|
||||
cannot have its fields moved out, so this is source-breaking for a
|
||||
consumer of the library crate even though nothing about the shipped
|
||||
binaries changes.
|
||||
|
||||
`IdentityConfig` is the one most likely to be reached in practice,
|
||||
because it hangs off the public `Config` as `node.identity`. Code that
|
||||
moved the nsec out of a configuration value no longer compiles. The fix
|
||||
is `Option::take` on the field rather than moving the value out.
|
||||
|
||||
This is a source break in a patch release, which semantic versioning
|
||||
does not sanction. It ships anyway because the alternative was holding a
|
||||
security fix for the next minor, and because the crate is not published
|
||||
to a registry, so the reachable population is small.
|
||||
|
||||
### During and after a rolling upgrade
|
||||
|
||||
- `TtlExhausted` charges at a different node than it did, so its
|
||||
distribution shifts by one hop while the mesh is mixed. This settles
|
||||
once every node reports `0.4.2`.
|
||||
- If you scrape the control socket, expect `show_routing` and
|
||||
`show_status` to carry new counters. An older `fipsctl` or `fipstop`
|
||||
gains unknown fields rather than losing known ones.
|
||||
- On macOS, move `peers.allow`, `peers.deny`, `hosts`, `fips.yaml` and
|
||||
`fips.key` from `/etc/fips/` to `/usr/local/etc/fips/`. The daemon
|
||||
warns once at startup if it finds any of them only at the old
|
||||
location, and it will adopt a key stranded there rather than
|
||||
generating a new identity, but the warning is the signal to move them
|
||||
rather than to leave them.
|
||||
- A mesh whose peers advertise a legitimately narrow path MTU should be
|
||||
watched once after the upgrade. Locally derived values are exempt from
|
||||
the new floor at both the seed and the clamp, so a narrow link is
|
||||
expected to adapt as before, but that exemption is asserted in the
|
||||
code rather than proven by a test that drives a genuinely narrow path.
|
||||
|
||||
Downgrading to v0.4.1 is supported. A config corrected for the three
|
||||
rules above still loads on v0.4.1, so the correction does not have to be
|
||||
reverted.
|
||||
|
||||
## Getting v0.4.2
|
||||
|
||||
- **Linux x86_64 / aarch64**: `.deb` and tarball at the
|
||||
[v0.4.2 release page](https://github.com/jmcorgan/fips/releases/tag/v0.4.2).
|
||||
- **Arch Linux**: `fips` from the AUR.
|
||||
- **macOS**: `.pkg` at the v0.4.2 release page.
|
||||
- **Windows**: ZIP at the v0.4.2 release page.
|
||||
- **OpenWrt**: `.ipk` (OpenWrt 24.x and earlier) or `.apk` (OpenWrt 25+)
|
||||
at the v0.4.2 release page.
|
||||
- **From source**: `cargo build --release` from a checkout of the v0.4.2
|
||||
tag (Rust 1.94.1 per `rust-toolchain.toml`; `libclang-dev` is a
|
||||
required Linux build prerequisite).
|
||||
- **Nix / NixOS**: `nix build .#fips` from a checkout of the v0.4.2 tag
|
||||
builds the binaries from source with the pinned toolchain and no
|
||||
manual prerequisites (see the Nix section of `packaging/README.md`).
|
||||
|
||||
The full per-commit changelog lives in
|
||||
[`CHANGELOG.md`](../../CHANGELOG.md). Issues and discussion at
|
||||
[github.com/jmcorgan/fips](https://github.com/jmcorgan/fips).
|
||||
|
||||
Security reports have a private channel as of this release; see
|
||||
[`SECURITY.md`](../../SECURITY.md).
|
||||
|
||||
## Contributors
|
||||
|
||||
Thanks to everyone who contributed code, packaging work, bug reports, or
|
||||
reviews to this release.
|
||||
|
||||
- [@jmcorgan](https://github.com/jmcorgan) (Johnathan Corgan): release
|
||||
shepherd; the session and handshake authentication work, path MTU
|
||||
bounding, key material protection and clearing, gateway DNS answer
|
||||
validation, Action pinning and the dependency refresh, the traversal
|
||||
clock fix, spanning-tree and rate-limiting work, and the test harness.
|
||||
- [@sh1ftred](https://github.com/sh1ftred): the macOS install layout
|
||||
fix, so config, ACL and identity paths follow the platform packaging
|
||||
([#132](https://github.com/jmcorgan/fips/pull/132)). First
|
||||
contribution to FIPS.
|
||||
|
||||
Bug reports and reviews that shaped this release:
|
||||
|
||||
- [@Theleifless](https://github.com/Theleifless): reported
|
||||
[#128](https://github.com/jmcorgan/fips/issues/128), NAT traversal
|
||||
breaking after the host sleeps.
|
||||
- [@ngmisl](https://github.com/ngmisl): filed
|
||||
[#137](https://github.com/jmcorgan/fips/issues/137), the security
|
||||
review most of this release's security work answers.
|
||||
Reference in New Issue
Block a user